Skip to content

python-ecosys/debugpy: Add VS Code debugging support for MicroPython. - #1022

Open
andrewleech wants to merge 32 commits into
micropython:masterfrom
andrewleech:add-debugpy-support
Open

python-ecosys/debugpy: Add VS Code debugging support for MicroPython.#1022
andrewleech wants to merge 32 commits into
micropython:masterfrom
andrewleech:add-debugpy-support

Conversation

@andrewleech

Copy link
Copy Markdown
Contributor

This implementation provides a Debug Adapter Protocol (DAP) server that enables VS Code to debug MicroPython code with full breakpoint, stepping, and variable inspection capabilities.

Features:

  • Manual breakpoints via debugpy.breakpoint()
  • Line breakpoints set from VS Code
  • Stack trace inspection
  • Variable scopes (locals/globals)
  • Source code viewing
  • Stepping (into/over/out)
  • Non-blocking architecture for MicroPython's single-threaded environment
  • Conditional debug logging based on VS Code's logToFile setting

Implementation highlights:

  • Uses MicroPython's sys.settrace() for execution monitoring
  • Handles path mapping between VS Code and MicroPython
  • Efficient O(n) fibonacci demo (was O(2^n) recursive)
  • Compatible with MicroPython's limited frame object attributes
  • Comprehensive DAP protocol support

Files:

  • README.md: Setup and usage instructions
  • debugpy/: Core debugging implementation
  • test_vscode.py: VS Code integration test
  • dap_monitor.py: Protocol debugging utility

Usage:

import debugpy
debugpy.listen()          # Start debug server
debugpy.debug_this_thread()  # Enable tracing
debugpy.breakpoint()      # Manual breakpoint

🤖 Generated with Claude Code


Currently only tested on unix port with updates to settrace in micropython/micropython#8767
Should work on any network enabled device however?

This implementation provides a Debug Adapter Protocol (DAP) server that enables
VS Code to debug MicroPython code with full breakpoint, stepping, and variable
inspection capabilities.

Features:
- Manual breakpoints via debugpy.breakpoint()
- Line breakpoints set from VS Code
- Stack trace inspection
- Variable scopes (locals/globals)
- Source code viewing
- Stepping (into/over/out)
- Non-blocking architecture for MicroPython's single-threaded environment
- Conditional debug logging based on VS Code's logToFile setting

Implementation highlights:
- Uses MicroPython's sys.settrace() for execution monitoring
- Handles path mapping between VS Code and MicroPython
- Efficient O(n) fibonacci demo (was O(2^n) recursive)
- Compatible with MicroPython's limited frame object attributes
- Comprehensive DAP protocol support

Files:
- debugpy/: Core debugging implementation
- test_vscode.py: VS Code integration test
- VSCODE_TESTING_GUIDE.md: Setup and usage instructions
- dap_monitor.py: Protocol debugging utility

Usage:
```python
import debugpy
debugpy.listen()          # Start debug server
debugpy.debug_this_thread()  # Enable tracing
debugpy.breakpoint()      # Manual breakpoint
```

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
@andrewleech

andrewleech commented Jun 11, 2025

Copy link
Copy Markdown
Contributor Author

For those interested in AI coding, this was 95% written by Claude Code (Opus and Sonnet 4) as mentioned in the attributions above. I started prompting the build of this just this morning at 5am from my armchair with my infant asleep in my arms, using Termux on my phone to ssh into my linux box.

I had a clone of micropython with my historical work on getting pdb checked out (micropython/micropython#8767 and #499)

I also had a copy of the official cpython debugpy package checked out, this is the package used behind the scenes to drive python debugging in VSCode and similar IDE's. From past reviews I knew debugpy relies on threads, the old pydevd network debug engine as well a large RPC server which runs in a thread.

I figured some of this could be re-implemented if needed, or replaced with other servers already running on micropython :-)

So I started with a claude session in the debugpy folder and performed an initial repo analysis /init before asking:

perform detailed analysis  of the behaviour of this starting with listen mode.  write detailed specifications of the network API exposed and the data formats used there. then  document the pathways taken to get from network API down to the systrace api

Which produced DEBUGPY_ARCHITECTURE_ANALYSIS.md

I then kicked off : [corona@Telie micropython]$ claude --add-dir ~/debugpy/
and gave it this to kick off:

╭──────────────────────────────────────────────────────╮
│ > new  feature task; adding remote python debugging  │
│   support to micropython. I want to attach a         │
│   debugging session in vscode to a micropython       │
│   instance via debugpy.  we have a copy of cpython   │
│   debugpy to reference in ~/debugpy with an          │
│   analysis of its architecture in                    │
│   ~/debugpy/DEBUGPY_ARCHITECTURE_ANALYSIS.md I want  │
│   a minimal port of this library to micropython,     │
│   implementing the network listening interface       │
│   through to the pdb/systrace debug layer.there is   │
│   the micropython pdb implementation available at    │
│   ./lib/micropython-lib/python-stdlib/pdb/pdb.py     │
│   and any other dependencies should searched for     │
│   under ./lib/micropython-lib/ otherwise they might  │
│   need to also be implemented. the new debugpy       │
│   implementation can copy any of the cpython one     │
│   that makes sense, though for micropython smaller   │
│   is better. it should be built in a new folder:     │
│   ./lib/micropython-lib/python-ecosys/debugpy and    │
│   can be tested with the coverage variant of the     │
│   Unix port: /home/corona/micropython4/ports/unix/b  │
│   uild-coverage/micropython/home/corona/micropython  │
│   4/ports/unix/build-coverage/micropython            │
╰──────────────────────────────────────────────────────╯

WIthin just 1 hour of armchair vibe coding I had an initial implementation ready to test, along with test scripts and a written plan.

Around 10 am I was at my desk and had finished my morning meetings, so started testing it in the background while working on my other "real" projects.

It tooks quite a few iterations of testing in vscode for Claude to finish its implementation plan, adding features as it ran test scripts with me hitting the vscode "debug" button in between. Most of these tests failed badly in many different ways, enough that I was quite pessimistic at times because it really looked like it wasn't going to work ..... however I was still able to get other solid work done though during this time (which coincidentally was also using Claude Code; I've had 4 sessions actively on the go today) so I gave Claude a few chances to get it all going after a number of wrong paths were backtracked.

After all that though this screen capture was at 2:12 pm (and yes I ate lunch during that time too):
DebuggingMicropython

It took me a while to realise while reviewing afterwards and cleaning up the git tree that it hasn't actually pulled in pdb as a dependency, but re-implemented a simple version of everything needed but not much more; it's a rather minimal implementation!

@Josverl

Josverl commented Jun 11, 2025

Copy link
Copy Markdown
Contributor

Thanks Andrew,
very nice to see.
It took me more than a few attempts to get this running, but very rewarding.
Perhaps rebasing both PRs to master , and some tweaks fix mismatches in the names of the samples to will help there.
Its good to be able to do a even a simple step -by-step , even though there is now even more to be desired.

"configurations": [
{
"name": "Attach to MicroPython",
"type": "python",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"type": "debugpy",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, yeah I fixed that in the examples file, missed it here

continue

try:
value_str = str(value)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be value_str = repr(value) . without that strings show without quotes etc.

@andrewleech

Copy link
Copy Markdown
Contributor Author

Thanks @Josverl good to hear it either for you, I still could hardly believe it worked for me!

I'd be interested to hear any notes about what was confusing / difficult to get going to feed into docs.

I assume some of it was getting paths right to import stuff? And/or compiling with the other features needed? Aka things that'll be better once finished and merged...

I will do some testing on hardware too, ensure that does work and document how to get it going.

I did think the branches were pretty well rebased up to date, I'll double check.

Oh yeah I'll eventually look into getting a useful representation of locals too, even if they end up basically just showing the array of values without names as per the current internal representation.

1. Build the MicroPython Unix coverage port:
```bash
cd ports/unix
make CFLAGS_EXTRA="-DMICROPY_PY_SYS_SETTRACE=1"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this flag MICROPY_PY_SYS_SETTRACE conflicts with the referenced PR where this is already set unconditionally

@Josverl

Josverl commented Jun 12, 2025

Copy link
Copy Markdown
Contributor

I'd be interested to hear any notes about what was confusing / difficult to get going to feed into docs.

I first got in a tangle by

  • checking out the micropython repo on PR#8767
  • then checking out ./lib/micropython-lib on this PR

blocked most attempts at building as make submodules breaks
After reverting ./lib/micropython-lib, and cloning this PR to a separate folder things got better

Building a firmware with "the updates to settrace in micropython/micropython#8767"
I still ran into build problems , essentially by a double definition of MICROPY_PY_SYS_SETTRACE .
In the readme of this PR it is part of the instructions , while in the "settrace PR" it is unconditionally defined. These two clash.

Matching up the paths vscode / remote paths, was not to difficult.

Open questions/ more play time needed :

  • I am confused by the read-only source used by the debugger to step though. I can see it is retrieved from the remote, but not sure why this is needed , or how to configure it.
  • why JustMycode : false is needed
  • how debugging would work with a .mpy on the remote
  • How to Enable VS Code's built-in DAP logging: ( I do not have these settings in my VSCode)
  • Why the DAP_Monitor does not terminate or reset itself when the remote target exits

📚 likely relevant : What is the Debug Adapter Protocol?

@Josverl

Josverl commented Jun 12, 2025

Copy link
Copy Markdown
Contributor

@andrewleech
I have made some more notes after some additional testing I have done.
I have put them in a Gist notes.md for now as I do not want to drop too many comments here.
Would it make sense to start a Discussion on this topic , or do you prefer everything here ?

@andrewleech

andrewleech commented Jun 15, 2025

Copy link
Copy Markdown
Contributor Author

I'd be interested to hear any notes about what was confusing / difficult to get going to feed into docs.

I first got in a tangle by

  • checking out the micropython repo on PR#8767
  • then checking out ./lib/micropython-lib on this PR

blocked most attempts at building as make submodules breaks
After reverting ./lib/micropython-lib, and cloning this PR to a separate folder things got better

Yes the automatic make submodules in mpbuild pretty much breaks development within micropython-lib, I haven't figured out any way to resolve this without a newer/smarter algorithm in the make submodules target (which I haven't written yet)

Building a firmware with "the updates to settrace in micropython/micropython#8767"
I still ran into build problems , essentially by a double definition of MICROPY_PY_SYS_SETTRACE .
In the readme of this PR it is part of the instructions , while in the "settrace PR" it is unconditionally defined. These two clash.

Ah yeah, the readme in micropython-lib doesn't necessarily know about the updates in the micropython PR,

Matching up the paths vscode / remote paths, was not to difficult.

Open questions/ more play time needed :

  • I am confused by the read-only source used by the debugger to step though. I can see it is retrieved from the remote, but not sure why this is needed , or how to configure it.

I haven't figured out the read only src either, it's annoying though. I think it's related to path matching issue between local and remote copies.

  • why JustMycode : false is needed

Not sure if it is really needed?

  • how debugging would work with a .mpy on the remote

The path matching would need to be smart enough to match PC side py to remote module (should be possible) and the mpy would need to have been made with opt=0 otherwise line numbers are stripped.

  • How to Enable VS Code's built-in DAP logging: ( I do not have these settings in my VSCode)

Ah yeah I didn't actually test that!

  • Why the DAP_Monitor does not terminate or reset itself when the remote target exits

Yeah that would be helpful, I'll add it

📚 likely relevant : What is the Debug Adapter Protocol?

Definitely a good reference to add to the docs thanks

@Josverl

Josverl commented Jun 15, 2025

Copy link
Copy Markdown
Contributor

Why the DAP_Monitor does not terminate or reset itself when the remote target exits

Yeah that would be helpful, I'll add it

I did some work to terminate on disconnect
See the PR to your fork

Josverl and others added 4 commits June 15, 2025 20:04
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
When breakpoints are hit, VS Code was opening read-only copies of source
files instead of the original workspace files due to path mismatches between
VS Code's absolute paths and MicroPython's runtime paths.

Changes:
- Add path mapping dictionary to track VS Code path <-> runtime path relationships
- Enhance breakpoint matching to handle relative paths and basename matches
- Update stack trace reporting to use mapped VS Code paths
- Add debug logging for path mapping diagnostics
- Fix VS Code launch configuration (debugpy -> python, enable logging)

This ensures VS Code correctly opens the original editable source files
when debugging, rather than creating read-only temporary copies.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrew Leech <andrew.leech@planetinnovation.com.au>
@Josverl

Josverl commented Jun 16, 2025

Copy link
Copy Markdown
Contributor

I was able to add (a first rough draft) of support for frame.f_locals on top of the pdb_support branch.
image

@andrewleech

Copy link
Copy Markdown
Contributor Author

I was able to add (a first rough draft) of support for frame.f_locals on top of the pdb_support branch.

Oh wow awesome, I wanted to tackle basically exactly that! Looks great :-D
I was only just about to test my latest changes to this micropython-lib branch; hopefully the read-only window no longer pops up...

@Josverl

Josverl commented Jun 17, 2025

Copy link
Copy Markdown
Contributor

Now also able to resolve the names of local variables, at the cost of some memory per frame, and a change in the compiler.

Still a lot of checking and cleanup to do in that part of the code though

Josverl added 11 commits June 19, 2025 01:02
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Store both folder mappings from the debugger, and 1:1 file mappings .

Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Josverl and others added 13 commits July 1, 2025 00:15
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
(cherry picked from commit 215300dad99c2dab2adbf8d48e7044508b17b3e9)
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
recv_message() stripped the header from the receive buffer as soon as the
CRLF/CRLF terminator was found, but only persisted buffer state on some
partial-read paths. When a message body arrived in a later read than its
header, the parsed-header state was lost and framing desynchronised for the
rest of the connection. Keep the header and body together in the buffer until
the whole message (header + Content-Length bytes) is present, then slice it off.
Treat an empty recv as a peer close and EAGAIN/EWOULDBLOCK as "try later".

Claude-Session: https://claude.ai/code/session_013VDeuZRScEaKtvZzn2ehyq
- wait_for_client() blocks until the DAP client sends configurationDone,
  draining the socket so breakpoints set beforehand are honoured; replaces a
  fixed sleep. Bounded timeout, logged rather than silent.
- Runtime capability probe (settrace / save_names / set_local / f_back) derived
  by exercising the interpreter, never inferred from a build or variant name;
  exposed via get_capabilities().
- Local variables are marked read-only (DAP presentationHint) when the firmware
  lacks frame._set_local, so clients do not offer an edit that cannot work;
  globals stay editable.
- listen() resolves the actually-bound port and never advertises port 0.

Claude-Session: https://claude.ai/code/session_013VDeuZRScEaKtvZzn2ehyq
andrewleech pushed a commit to andrewleech/micropython-lib that referenced this pull request Jul 5, 2026
andrewleech pushed a commit to andrewleech/micropython-lib that referenced this pull request Jul 14, 2026
debugpy DAP server (PR micropython#1022)

MBM-PR: 1022
MBM-URL: micropython#1022
DAP `evaluate` requests carry a `context` field (`watch`, `hover`, `repl`,
`clipboard`, ...) that `_handle_evaluate` read but discarded, so every
request went through `eval()` only; a statement such as `x = 5` or
`def f(): ...` typed into the Debug Console failed with a syntax error
instead of running.

`evaluate_expression` now dispatches on `context`: `watch`/`hover` (and
any other or absent context) keep the original eval-only, read-only
contract unchanged. `repl`/`clipboard` try `eval()` first, so a plain
expression like `1 + 1` still returns a value, and only fall back to
`exec(expression, globals_dict)` when `eval()` raises `SyntaxError`.

The exec namespace is globals-only, on purpose: `exec(code, g, l)` binds
a top-level assignment into `l`, and here `l` is a throwaway copy of the
paused frame's `f_locals` snapshot handed back to the caller and then
discarded, so the assignment would silently vanish instead of taking
effect. Passing only `globals_dict` makes a statement's assignments land
in the running module namespace, where they are visible to the target
program after `continue`.

That globals-only exec creates a shadowing hazard: assigning a name that
is also a LOCAL of the paused frame changes the global but leaves the
local exactly as it was, which looks like a no-op from the Debug
Console's perspective. `_shadowed_local_warning` detects the common case
(a simple `name = ...` or `name op= ...` at the start of the statement)
and appends a warning to the result so the mismatch is visible rather
than silently misleading; it does not attempt to parse multi-target
assignment, unpacking, attribute/subscript targets, or `def`/`class`/`for`
bindings, and a `None` result from `_assigned_name` means "not proven
safe", never "proven no shadowing".
andrewleech pushed a commit to andrewleech/micropython-lib that referenced this pull request Jul 14, 2026
debugpy DAP server (PR micropython#1022)

MBM-PR: 1022
MBM-URL: micropython#1022
andrewleech pushed a commit to andrewleech/micropython-lib that referenced this pull request Aug 4, 2026
debugpy DAP server (PR micropython#1022)

MBM-PR: 1022
MBM-URL: micropython#1022
listen() bound the socket, blocked in accept() and handled the client's
initialize request before returning, so a caller could only learn the
endpoint after a client had already connected to it - unusable for any
orchestration that has to read the address in order to attach.

listen() now returns as soon as the socket is bound. The accept and the
initialize handshake move into wait_for_client(), which creates the
session. This matches CPython debugpy, where listen() reports the
endpoint and wait_for_client() blocks.

port=0 now raises instead of substituting DEFAULT_PORT when the target's
getsockname() cannot report the assigned port: callers act on the
returned endpoint, so naming an address the socket is not bound to sends
them somewhere nothing is listening.

Signed-off-by: Andrew Leech <andrew.leech@planetinnovation.com.au>

Claude-Session: https://claude.ai/code/session_01PxZTAGYHMm6i8CUF4tW885
andrewleech pushed a commit to andrewleech/micropython-lib that referenced this pull request Aug 5, 2026
debugpy DAP server (PR micropython#1022)

MBM-PR: 1022
MBM-URL: micropython#1022
process_pending_messages() set a 1 ms socket timeout and restored blocking
mode in its finally. The trace function calls it on entry to every new
frame, so handling a message re-enters it, and the inner call's finally put
the socket back into blocking mode underneath the outer loop. That loop's
next recv() then waited for a message the client will not send until it has
seen an event the loop itself is what produces - a deadlock between the two
sides.

It only bites when the clobber lands inside the window after
configurationDone, which is why it presented as a load-sensitive flake: the
session hangs before wait_for_client() returns, so the target never runs and
no stopped event is ever produced.

The nesting is tracked rather than the timeout saved and restored, because
MicroPython sockets have no gettimeout().

Measured on the wrapper repo's harness: the previously worst-affected file
went from 4 clean runs in 6 to 6 in 6, and the full suite from 0 clean in 3
to 3 in 4.

Signed-off-by: Andrew Leech <andrew.leech@planetinnovation.com.au>

Claude-Session: https://claude.ai/code/session_01PxZTAGYHMm6i8CUF4tW885
andrewleech pushed a commit to andrewleech/micropython-lib that referenced this pull request Aug 6, 2026
debugpy DAP server (PR micropython#1022)

MBM-PR: 1022
MBM-URL: micropython#1022
@andrewleech

Copy link
Copy Markdown
Contributor Author

Pushed an update folding in the enhancement lineage that had been developed alongside this branch, plus several fixes found while building tooling on top of it. Fast-forward, no history rewritten.

Two are correctness fixes that affect anyone using this today:

Nested message pumps deadlocked the session. process_pending_messages() set a 1 ms socket timeout and restored blocking mode in its finally. The trace function calls it on entry to every new frame, so handling a message re-enters it, and the inner call's finally put the socket back into blocking mode underneath the outer pump. That pump's next recv() then waited for a message the client won't send until it has seen an event the pump itself produces. When it lost the race the session hung before wait_for_client() returned, so the target never ran and no stopped event was ever produced. It also cost ~100 ms per traced statement when it didn't deadlock. The nesting is now tracked rather than the timeout saved and restored, since MicroPython sockets have no gettimeout().

listen() now returns the bound endpoint before accepting. It previously bound, blocked in accept(), and handled initialize before returning, so a caller could only learn the endpoint after a client had already connected to it — unusable for anything that has to read the address in order to attach. accept() and the initialize handshake move into wait_for_client(), matching CPython debugpy, where listen() reports the endpoint and wait_for_client() blocks. Relatedly, port=0 now raises where getsockname() can't report the assigned port instead of silently substituting the default and advertising an address nothing is bound to.

Also included: DAP messages split across recv() calls are reassembled; a runtime capability probe (settrace/save_names/set_local/f_back) so tooling can ask what a firmware actually supports rather than inferring from a build name; a read-only hint on locals where the firmware can't write them back; evaluate honouring its context field so statements work from the Debug Console; and the pause / set-variable / local-modification work from the parallel lineage.

Exercised by a host-side DAP harness (a fake VS Code client driving real sessions against a built unix firmware): 278 passed. Happy to split any of this out if you'd rather review it separately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants